Spring Security | Note-9

Spring Security Note-9


开发QQ登录

所有的Api都继承AbstractOAuth2ApiBinding

AbstractOAuth2ApiBinding抽象类中有两个属性

1
2
private final String accessToken;
private RestTemplate restTemplate;

现在写的整个Api在整个流程当中,是负责执行第六步(获取用户信息),要执行第六步,需要第五步最后收到的令牌(Token),拿着令牌才能获取用户信息;

accessToken:存取前五步完成后,获取到的令牌信息,这是一个类级别的全局对象,在整个我们需要完成的QQImpl中,不是一个单例对象,对每个用户都会有个QQImpl的单独的实现,然后存取用户自己独有的accessToken,这是一个多实例的对象;

restTemplate:在第六步获取用户信息,要往服务器提供商发往一个HTTP请求,restTemplate就是帮助发送HTTP请求的;

获取用户信息之前,我们需要前查看QQ开发的文档QQ互联

获取用户信息接口的实现

继承,AbstractOAuth2ApiBinding中accessToken是存放前面5步获取的用户信息的;

每个用户的用户信息都不相同,因此QQImpl是个多实例的;

因此不能将此类申明成为Spring的一个组件,需要的时候new就可以了;

需要的参数:

appId:注册qq互联分配的appid

openId:qq用户的Id

accessToken:父类提供

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {
private static final String URL_GET_OPENID = "https://graph.qq.com/oauth2.0/me?access_token=%s";
private static final String URL_GET_USERINFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";

private String appId;
private String openId;

private ObjectMapper objectMapper = new ObjectMapper();

public QQImpl(String accessToken,String appId){
// 调用父类构造方法时,将accessToken需要作为查询参数
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;
String url = String.format(URL_GET_OPENID,accessToken);
// getRestTemplate父类提供
String result = getRestTemplate().getForObject(url,String.class);
System.out.println(result);

this.openId = StringUtils.substringBetween(result,"\"openid\":", "}");
}

@Override
public QQUserInfo getUserInfo() throws IOException {
String url = String.format(URL_GET_USERINFO,appId,openId);
String result = getRestTemplate().getForObject(url,String.class);
System.out.println(result);
return objectMapper.readValue(result,QQUserInfo.class);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
private String appId;

// 将用户导向的认证服务器的地址
private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
// 第三方拿着授权码获取Token的地址
private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";

// 提供OAuth2Operations
public QQServiceProvider(String appId, String appSecret) {
super(new OAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
}

@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken, appId);
}
}

创建ConnectionFactory

Service Provider上部已完成,接下来开发另一部分ApiAdapter;

ApiAdapter将服务提供商用户基本信息进行统一的适配作用;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class QQAdapter implements ApiAdapter<QQ>{
@Override
public boolean test(QQ qq) {
return true;
}

/**
* 设置创建Connection 需要的一些配置项ConnectionValues
* @param api
* @param values
*/
@Override
public void setConnectionValues(QQ api, ConnectionValues values) {
QQUserInfo userInfo = api.getUserInfo();
values.setDisplayName(userInfo.getNickname());
values.setImageUrl(userInfo.getFigureurl_qq_1());
// QQ无个人主页
values.setProfileUrl(null);
// 用户所在服务提供商的唯一标识
values.setProviderUserId(userInfo.getOpenId());
}

/**
* 绑定用户和解绑用户
* @param qq
* @return
*/
@Override
public UserProfile fetchUserProfile(QQ qq) {
return null;
}
@Override
public void updateStatus(QQ qq, String s) {
}
}

调回自建Connection

1
2
3
4
5
6
7
8
9
/**
* 将之前完成的QQServiceProvider和QQAdapter传递进来并创建
* @Author: REX
*/
public class QQConectionFactory extends OAuth2ConnectionFactory<QQ> {
public QQConectionFactory(String providerId,String appId,String appSecret) {
super(providerId, new QQServiceProvider(appId,appSecret), new QQAdapter());
}
}

创建SocialConfig配置类

配置UserConnection表相关设置,注入SpringSocialConfigure;

1
2
3
4
5
6
7
8
9
10
11
12
13
create table UserConnection (userId varchar(255) not null,
providerId varchar(255) not null,
providerUserId varchar(255),
rank int not null,
displayName varchar(255),
profileUrl varchar(512),
imageUrl varchar(512),
accessToken varchar(512) not null,
secret varchar(512),
refreshToken varchar(512),
expireTime bigint,
primary key (userId, providerId, providerUserId));
create unique index UserConnectionRank on UserConnection(userId, providerId, rank);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
/*
* 操作UserConnection表的配置类
* 配置getUsersConnectionRepository操作Connection数据库的表
* 在这里面可以定义创建表的前缀信息和存入数据库数据的加解密的方法
* JdbcUsersConnectionRepository.sql中有建表的语句:
* */

@Autowired
private DataSource dataSource;

@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator, Encryptors.noOpText());
// 前缀
repository.setTablePrefix("t_");
return repository;
}
// 将SpringSocialFilter添加到安全配置的Bean
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
return new SpringSocialConfigurer();
}
}

添加配置

1
2
3
public class QQProperties extends SocialProperties{
private String providerId = "qq";
}
1
2
3
public class SocialProperties {
private QQProperties qq = new QQProperties();
}
1
2
3
4
5
6
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
private ValidateCodeProperties code = new ValidateCodeProperties();
private SocialProperties social = new SocialProperties();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
// 当配置了imooc.security.social.qq.app-id时才生效
@ConditionalOnProperty(prefix = "security.social.qq",name = "app-id")
public class QQAutoConfig extends SocialAutoConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;

/**
* 将配置文件中的ProviderId,AppId,AppSecret读取出来,给QQConnectionFactory
* @return
*/
@Override
protected ConnectionFactory<?> createConnectionFactory() {
QQProperties qqConfig = securityProperties.getSocial().getQq();
return new QQConectionFactory(qqConfig.getProviderId(), qqConfig.getAppId(),qqConfig.getAppSecret());
}
}
1
2
security.social.qq.app-id=xxx
security.social.qq.app-secret=xxx

完善

在上部分完成所有组件的配置之后,测试QQ登录,会发现报错:

redirect uri is illegal(100010)

在我们进行QQ登录的时候引导用户到认证服务器并授权后跳转回引导的地址;

所以要求我们发起QQ登录的请求和我们在QQ互联系统上配置的请求QQ要保持一致;

否则跳转回来的时候,携带授权码的请求就不会被第三方应用处理,这就导致了发生错误redirect uri is illegal:

方法就是设置server.port=80,这样也能访问成功,引导用户到认证服务器也能成功;

1
server.port=80

要想社交登录成功,我们最后还需要将spring social的过滤器配到我们的过滤器链中;

这个过滤器配置的Bean我们写在SocialConfig类中的ImoocSocialSecurityConfig方法中;

这个方法最终返回一个ImoocSpringSocialConfigurer对象;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ImoocSpringSocialConfigurer extends SpringSocialConfigurer{
private String filterProcessesUrl;

public ImoocSpringSocialConfigurer(String filterProcessesUrl) {
this.filterProcessesUrl = filterProcessesUrl;
}

@Override
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter)super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return super.postProcess(object);
}
}

这个继承的父类SpringSocialConfigurer类中进行了过滤器的配置,在SpringSocialConfigurer类的源码中发现,在configure方法中先实例化了一个过滤器,然后将SocialAuthenticationFilter 过滤器加到了过滤器链中

1
2
3
4
public class SocialProperties {
private QQProperties qq = new QQProperties();
private String filterProcessesUrl = "/auth";
}
1
2
3
4
5
6
7
// 将SpringSocialFilter添加到安全配置的Bean
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
ImoocSpringSocialConfigurer configurer = new ImoocSpringSocialConfigurer(filterProcessesUrl);
return configurer;
}

第一步请求授权的过滤器SocialAuthenticationFilter类开始;

这个方法用户处理请求;

1
auth = attemptAuthService(authService, request, response);

对应流程图中SocialAuthenticationService类如何创建出Authentication认证信息;

传入的SocialAuthenticationService对象通过调用getAuthToken方法,直接就获取了通过授权码来获取到的token,由于SocialAuthenticationService是一个接口,具体的实现在SocialAuthenticationService接口的实现类OAuth2AuthenticationService的getAuthToken方法中;

实际上过滤器拦截的请求“\login\qq”既是第一步将用户导向认证服务器的请求,也是认证服务器返回授权码给第三方用户的返回请求,所以这个方法第一句就对这两种请求进行区分:

如果授权码code为空

则说明是第一次登陆,则抛出一个重定向的异常SocialAuthenticationRedirectException,参数

1
getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params)

发现这个地址就是我们之前在QQConnectionFactory中的QQServiceProvider参数中配置的地址拼凑起来的,通过这个地址将用户导向到QQ的用户登录页面;

如果授权码不为空

说明是QQ携带授权码返回给第三方应用的请求,

1
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);

就是通过ConnectionFactory中的QQServiceProvider中的OAuth2Operations实现类来做拿着授权码去获取access_token的操作。OAuth2Operations接口的实现是我们之前配的OAuth2Template类;

在这个类中发起获取token的请求

1
getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class QQOAuth2Template extends OAuth2Template {
private Logger logger = LoggerFactory.getLogger(getClass());

public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
// useParametersForClientAuthentication为true时exchangeForAccess方法
setUseParametersForClientAuthentication(true);
}

// QQ互联获取accessToke的响应返回的是&拼接的字符串
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);

logger.info("获取accessToke的响应:" + responseStr);

String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");

String accessToken = StringUtils.substringAfterLast(items[0], "=");
Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));
String refreshToken = StringUtils.substringAfterLast(items[2], "=");

return new AccessGrant(accessToken, null, refreshToken, expiresIn);
}

// 重写createRestTemplate,解决不能处理text/html
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}

}

通过获取token的请求accessTokenUrl来获取token等信息后封装在AccessGrant实例中,而且要求返回格式为map,为此我们复写的这个方法要求返回的格式为String.class,即返回字符串responseStr;

并截取出accessToken,expireIn ,refreshToken 这三个参数,最后保存到AccessGrant对象中;

此外复写的createRestTemplate方法也是为了RestTemplate 实例可以接受返回的UTF-8格式的字符串;


上部分时,成功封装了用户信息到了SocialAuthenticationProvider中出现了问题,页面重新引发跳转到signup;

在SocialAuthenticationProvider类中,有一个authenticate的方法,它接收一个Authentication;

接收它首先要判断进来的是一个SocialAuthenticationToken的信息;

拿到之后,会调用一个叫toUserId(connection)的方法;

在传入的connection当中,有服务提供商返回的用户信息,在key里面,有两个主要的属性:

providerId & providerUserId

SpringSocial拿到这两个值以后,在toUserId()方法里面,它会使用usersConnectionRepository去数据库中查询刚刚这个Key有没有对应的用户ID;

由于是第一次登录,肯定是不存在数据的,拿不到用户ID,将会抛出一个BadCredentialsException;

将会交由Social AuthenticationFilter处理,做出一个判断,判断这个过滤器中,singupUrl是否为空,否则会认为有一个注册的页面,在此我们应该去定义一个注册的页面;

1
2
3
4
5
6
7
@Bean
public SpringSocialConfigurer imoocSocialSecurityConfig() {
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
ImoocSpringSocialConfigurer configurer = new ImoocSpringSocialConfigurer(filterProcessesUrl);
configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl());
return configurer;
}
1
2
3
4
5
6
7
8
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping("/regist")
public void regist(User user){
//注册用户
}
}

为了使跳转到注册页面时携带QQ的相关信息和注册完成后,将第三方的唯一标示传递给Social插入userConnections数据库中;

1
2
3
4
5
6
7
8
9
10
/**
* 解决两个问题,跳转到注册页时返回的用户信息
* 并且在注册成功时将用户唯一标识放入social中,存到数据库
* @param connectionFactoryLocator
* @return
*/
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator){
return new ProviderSignInUtils(connectionFactoryLocator,getUsersConnectionRepository(connectionFactoryLocator);)
}
在BrowserSecurityController中添加获取QQ用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class BrowserSecurityController {
@Autowired
private ProviderSignInUtils providerSignInUtils;

// 返回SocialUserInfo
@GetMapping("/social/user")
public SocialUserInfo getSocialUserInfo(HttpServletRequest request){
SocialUserInfo userInfo = new SocialUserInfo();
Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
userInfo.setProviderId(connection.getKey().getProviderId());
userInfo.setProviderUserId(connection.getKey().getProviderUserId());
userInfo.setNickname(connection.getDisplayName());
userInfo.setHeadimg(connection.getImageUrl());
return userInfo;
}
}
封装SocialUserInfo
1
2
3
4
5
6
public class SocialUserInfo {
private String providerId;
private String providerUserId;
private String nickname;
private String headimg;
}

在用户进行第三方用户和QQ用户进行注册绑定时将用户唯一标示给Social,并插入UsersConnection;

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private ProviderSignInUtils providerSignInUtils;
@PostMapping("/regist")
public void regist(User user, HttpServletRequest request){
// 注册用户
// 不管是注册还是绑定用户,都拿到一个用户唯一标识
String userId = user.getUsername();
providerSignInUtils.doPostSignUp(userId,new ServletWebRequest(request));
}
}

还有一种情况,就是将QQ的用户信息默认注册为一个在第三方中的用户:

1
2
3
4
5
6
7
8
@Component
public class DemoConnectionSignUp implements ConnectionSignUp {
@Override
public String execute(Connection<?> connection) {
// 根据社交用户信息默认创建用户并且返回用户唯一标识
return connection.getDisplayName();
}
}
修改SocialConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Order(1)
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
@Autowired(required = false)
private ConnectionSignUp connectionSignUp;
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator, Encryptors.noOpText());
// 前缀
repository.setTablePrefix("t_");
if(connectionSignUp != null){
repository.setConnectionSignUp(connectionSignUp);
}
return repository;
}
}